查看原文
其他

Go服务在容器内CPU使用率异常问题排查手记

王立明 58技术 2022-03-15


导语

本文介绍了公司“云化服务”的大背景下,将一个Go服务迁移至公司的基于K8s+docker的容器云平台,使用火焰图进行性能排查和优化方面的实践。欢迎在留言区进行阅读探讨。


背景

在公司“云化服务”的大背景下,将一个Go服务迁移至公司的基于K8s+docker的容器云平台。在迁移过程中发现服务在Docker容器内的CPU使用率异常的问题。针对此问题,进行了一些排查和优化的实践。本文将重现排查过程以及优化方案,希望能为读者提供一些参考。

问题现象

在将服务由物理机迁移至容器云计算平台过程中,发现CPU使用率远超预期。
该服务是一个Go编写的消息推送服务,其业务特点是:短时间内会推送大量消息,因此该服务的负载曲线会是一个类似方波的图形。
在迁移前预估业务高峰期的CPU使用率为20%,但是实际的CPU使用率远超预期,到达了70%,是预估值的3倍,如下图所示:




灰度迁移中容器内CPU使用率异常图
使用指令 pidstat -w 观察到线程切换次数也比较高,达到了千次/秒。

排查过程

1. 采样
在确认容器节点和物理机节点的请求量负载基本一致后,开始对容器节点进行性能排查。
Linux平台有很多性能分析工具像perf、systemtap等。Go的工具集非常丰富,相比于其他Linux工具,可以更加简便深入地进行分析调试。这里直接使用go tool pprof对服务进行profiling采样分析。

a) 首先在代码中开启pprof,对于具备http server的服务来说,仅需增加一行代码 import _ "net/http/pprof"

b) 访问 http://ip:port/debug/pprof/ 可以在浏览器中查看pprof采样得到的数据,使用命令行进行采样则更加方便 go tool pprof http://ip:port/debug/pprof/profile?seconds=30





命令行内查看pprof

c) 在pprof中输入指令web,即可生成一个函数调用链的CPU耗时分析,但是通常对于一个线上逻辑较为复杂的服务来说,此图并不直观,以火焰图的方式查看效果更佳

d) 在Go 1.10之后,官方的pprof工具直接支持了火焰图展示。目前由uber开源的第三方工具go-torch更为常用,这里使用了 go-torch -b ./pprof.demo.cpu.pb.gz -f demo.svg生成了火焰图如下所示:





CPU异常时的火焰图
2. 分析
火焰图的颜色不代表实际意义,纵轴代表代码函数调用栈,横轴代表CPU占用百分比,横轴的不同部分代码块是按照字母顺序排序。
具体对火焰图进行分析:

a) 总体来看 runtime 相关代码占据了60%左右的CPU,实际的业务代码占据了40%左右的CPU

b) 由于所使用的消息队列的客户端是使用Go调用C通过 cgo机制实现的,而cgo是较为缓慢和消耗CPU的,因此这里的 runtime._ExternalCode 会占用较多的CPU,是符合正常逻辑的

c) runtime中占用CPU比例较大的是:runtime.gcBgMarkWorker, runtime.schedule, runtime.findrunnable,正常情况下不应占据如此多的CPU。

翻看Go的相关源码发现:
在Go的运行模型GMP中,每个P会运行一个gcBgMarkWorker用于垃圾回收。




Go SDK源码
于是合理地进行假设:
是否由于P的数量不正确导致GC过多,从而CPU使用率过高?
Go程序在运行时,会使用查询到的CPU的数量作为默认的P的数量,简单地用一个Go脚本验证一下:
func main() { cpu := runtime.NumCPU() procs := runtime.GOMAXPROCS(0) fmt.Println("cpu num:", cpu, " GOMAXPROCS:", procs)}// output -> cpu num:32 GOMAXPROCS:32
该脚本运行结果表明:在程序运行时读取到的CPU的数量是宿主机的CPU数量,而不是容器设置的CPU核心数量。

3. 验证
通过环境变量GOMAXPROCS可以设置Go运行时P的数量,设置环境变量GOMAXPROCS=8,这个值是容器分配的CPU核心数。灰度一台效果如下:




灰度后CPU使用率对比
对比未设置环境变量的节点,CPU峰值从69%下降到19%,效果非常明显。全量上线之后,CPU使用率保持了与预期值一致。

解决方案

由于Go程序本身的特性,在运行时会默认读取系统的CPU核心数作为最大的并行执行线程数。而在容器内,读取到的是宿主机的CPU核心数。在容器被分配的CPU核心数远小于宿主机的CPU核心数的情况下,就会发生CPU使用率异常升高的情况。出现问题的这个服务,其业务特点就是周期性的峰值QPS极高,所以会较为明显地观察出CPU使用率异常的现象。
通过配置环境变量 GOMAXPROCS,指定最大的并行执行线程数,可以解决CPU使用率异常的问题。
由于业务逻辑的不同,达到最佳性能的GOMAXPROCS也不同。《The Way to Go》曾给出过一个经验公式:GOMAXPROCS=CPU数量-1。
在容器中,通常设置成申请的核心数即可。

原理分析

1. G-M-P模型
Go程序的运行是使用协程的方式,在Go中协程被称之为goroutine。
在操作系统看来,所有程序都是以多线程(暂不细分LWP和线程的区别)的方式运行,而线程切换(context switch)对性能的影响还是比较高的。一次线程切换一般需要1000~2000ns,而一次goroutine协程切换一般需要200ns。
Go为了提高并发能力,代码中任务的执行,由运行在内核态的操作系统对线程的控制,转移为运行在用户态的Go scheduler对协程的控制,协程的调度在Go runtime中进行。




Go程序与OS关系图
实际的内核线程与goroutine之间的数量关系为M:N,在M个内核线程上会有N个goroutine。
Go的运行模型为GMP模型:

a) G:代表goroutine

b) M:代表实际的内核线程

c) P:代表虚拟的processer  

协程调度的核心思想是:

a) 重用线程,一个线程上会多次执行协程任务

b) 限制同时运行(不包含阻塞)的线程数为GOMAXPROCS,默认情况下就是 CPU 的核心数目

c) 线程私有的 runqueues,并且可以从其他线程 stealing goroutine 来运行,线程阻塞后,可以将 runqueues 传递给其他线程

在最初的设计中是没有P这个单元的,在增加了P这个逻辑processer后,并发能力大大增强。

2. 协程调度原理
在GMP模型下,协程调度原理如下:

a) 在程序启动时,默认地会启动CPU最大核心数量的P,同时为每个P分配一个实际的内核线程M。

b) 使用协程G来执行任务代码,包括GC等runtime代码。

c) 每一个协程G在创建之后,都会通过P来找到一个实际的内核线程M,由该M来执行G中的代码。

d) P通过队列来接收G,队列分为本地队列LocalQueue和全局队列GlobalQueue。

e) 如果某个P的本地队列无可执行的G,那么就会去全局队列里面去抽取一部分的G来执行。如果全局队列为空,那么就会随机选择其他的P,将被选中的P的本地队列中的G抽取一部分来执行。这个机制称之为work stealing。

f) 为了避免某个G占用了非常多的资源,有一个后台任务sysmon,用来检测G的运行时间。当G的运行时长超过10ms后,会被强制剥夺运行权限,将其放入全局队列之中。

g) 如果内核线程M由于发生系统调用、网络调用等产生了阻塞,为了最大地提高效率,P会暂时将M解绑,创建一个新的内核线程M或者找到其他可运行的线程,与其绑定,继续执行该P上面的待执行的G。

h) 如果某个G因为触发了GC或者atomic,mutex,channel等阻塞操作,为了避免阻塞,该G同样会被调度走,等待其处于可运行的状态后再被调度执行。





运行调度模型
3.  原因探究
在火焰图中runtime.gcBgMarkWorker, runtime.schedule, runtime.findrunnable 占用CPU较多:

a) 首先是GC占用过长的问题,源码中的注释写道,每个P都拥有一个GC后台协程。

如果宿主机的核心数是32,容器分配的核心数是8,那么程序运行时就会有32个P。因此就会有32个GC后台协程,那么runtime.gcBgMarkWorker就会占据很多CPU。





Go SDK源码

b) 前面提到Go的调度原理,P寻找可执行的G的顺序为:1.先检查P的本地队列 2.如果没找到,则去全局队列寻找 3.如果还没有找到,则去其他的P的本地队列里面去抽取一部分来执行。调度器的runtime.findrunnable函数就是执行的此流程。那么在宿主机核心数的数量远多于实际分配的核心数的情况下,就会有很多空闲的P需要执行runtime.findrunnable的流程,而且会出现work stealing这种现象。

c) 了解到容器主要使用CGroup的cpu.cfs_period_us和cpu.cfs_quota_us等参数来对进程使用的CPU资源进行限制的。在不进行绑核等配置的操作下,CPU资源的分配是按照使用情况而不是实际的核心来分配的。例如宿主机的核心数是32,容器分配的核心数是8,那么最多可以使用总计800%的CPU资源,这些资源会被平均分配到32个核心,每个核心占用25%的CPU。那么当达到配置的限额后,就会触发Linux的调度策略,导致线程切换增多。


问题扩展 

1. JDK
对于不能正确识别容器内分配的核心数的现象,JDK同样有此问题。JDK在JDK 8u191之后开始支持使用容器分配的CPU核心数。
https://www.oracle.com/technetwork/java/javase/8u191-relnotes-5032181.html.

2. automaxprocs
对于Go服务来说,可以通过环境变量GOMAXPROCS来设置合理的CPU核心数量。
另外,Uber开源了一个自动调整GOMAXPROCS的库:https://github.com/uber-go/automaxprocs.

总结

本文对迁移服务至容器过程中,出现的CPU使用率过高的问题进行了排查和优化。通过设置环境变量GOMAXPROCS的方式,限制了GMP模型中P的数量的方式,解决了CPU使用率过高的问题。然后对Go程序的GMP模型和调度原理进行了探究,并分析了本文中CPU使用率过高问题的原因。最后,对容器内CPU数量不能正确识别这个较为普遍的问题,展示了Java和Go两种解决方案。

参考文献
1. [Analysis of the Go runtime scheduler] 
http://www.cs.columbia.edu/~aho/cs6998/reports/12-12-11_DeshpandeSponslerWeiss_GO.pdf
2. [Go runtime scheduler]
https://speakerdeck.com/retervision/go-runtime-scheduler?slide=14
3. [深度解密Go语言之scheduler]
https://qcrao.com/2019/09/02/dive-into-go-scheduler/
4. [CPU considerations for Java applications running in Docker and Kubernetes]
https://medium.com/@christopher.batey/cpu-considerations-for-java-applications-running-in-docker-and-kubernetes-7925865235b7
5. [容器中某Go服务GC停顿经常超过100ms排查]
https://mp.weixin.qq.com/s/Lk1EbiT7WprVOyX_dXYMyg

作者简介
王立明 /  云平台部-平台应用部后台开发工程师,主要负责消息推送服务以及框架组件的开发。

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存